11장: 다시 돌려도 안전한 작업 (재수행 가능 작업)
원서: 『API Design Patterns』 (JJ Geewax, Manning) — 입문판으로 쉽게 풀어 씀
이 장에서 딱 5가지만 기억하면 됩니다. 1. 매번 똑같은 설정을 다시 입력하는 게 왜 위험한지 설명한다. 2. 설정을 저장해두는 그릇인 '잡(Job)'이 무엇인지 설명한다. 3. 잡을 실제로 돌리는 '실행(run) 메서드'가 무엇인지 설명한다. 4. 실행 결과를 영구히 남기는 '익스큐션(Execution)'이 무엇인지 설명한다. 5. 이 방식의 좋은 점과 번거로운 점을 구분한다.
11.0 시작하기 전에 — 이 장의 한 줄
지난 장에서 우리는 "오래 걸리는 작업을 비동기로 추적하는 법"을 배웠습니다. 이 장은 거기서 한 걸음 더 나갑니다. "같은 작업을 또 하고, 또 하고, 또 해야 할 때 어떻게 깔끔하게 만드느냐"가 주제입니다.
여기서 처음 나오는 말 몇 개는 본문에서 바로 쉽게 풀어 쓸게요. 어려운 약어는 미리 겁먹지 않아도 됩니다.
새 용어 미리보기 (첫 등장 때 다시 설명합니다) - 비동기 작업: "주문 넣고 자리에 앉아서 기다리는 것" 같은 일. 끝날 때까지 붙잡고 서 있지 않습니다. - LRO: "오래 걸리는 작업"을 부르는 약어. 0장 용어집 기준으로 씁니다. 여기선 "진행 상황을 물어볼 수 있는 영수증" 정도로 생각하세요. - 리소스: API가 다루는 '물건' 하나하나. "사용자", "채팅방"처럼 이름표가 붙은 데이터 덩어리입니다.
11.1 왜 '다시 돌려도 안전한 작업'이 필요할까?
먼저 터지는 장면부터
이렇게 짜면 터진 적 있죠?
카페에서 커피를 매번 이렇게 주문한다고 생각해봅시다. "아이스 아메리카노, 샷 2개, 시럽 1번, 얼음 적게, 텀블러에 담아서요." 한 번이면 괜찮습니다. 그런데 매일 아침 이걸 토씨 하나 안 틀리고 다시 말해야 한다면요? 어느 날 "샷 1개"라고 잘못 말하면 다른 커피가 나옵니다.
API도 똑같습니다. "채팅방을 백업해줘"라는 작업을 부를 때마다 저장 위치, 압축 방식, 암호화 키를 전부 다시 넘긴다면, 한 글자만 틀려도 엉뚱한 곳에 백업되거나 망가집니다.
그게 바로 '다시 돌려도 안전한 작업'이 풀려는 문제입니다.
그게 뭐냐면 — '재수행 가능 작업(Rerunnable Job)'
재수행 가능 작업은 한마디로 이렇습니다. 같은 설정으로 몇 번이고 다시 돌릴 수 있게 미리 저장해둔 작업입니다. 요리에 비유하면 '레시피'입니다. 레시피를 한 번 적어두면, 다음엔 재료 양을 일일이 외우지 않고 그대로 따라 하면 됩니다.
| 비유 | API/코드 | 위험·주의 |
|---|---|---|
| 매일 커피를 처음부터 다시 주문 | 주문형 메서드: 부를 때마다 모든 설정 전달 | 설정이 길수록 실수·누락 위험 |
| 단골 메뉴 '나의 커피'로 등록 | 재수행 가능 작업(Job): 설정을 한 번 저장 | 설정은 서버가 보관, 실행은 이름만 부름 |
| "나의 커피 한 잔" | run 메서드: 저장된 잡을 ID만으로 실행 | 추가 설정을 안 넘기니 실수가 줄어듦 |
용어 한 줄 정리 (0장 용어집 기준, 처음 보는 것만 풀어 씀) - 주문형 메서드(On-demand Method): 필요할 때마다 모든 설정을 직접 넘겨 부르는 방식. "매번 풀 주문서를 쓰는 것". - 잡(Job): 작업의 설정을 저장해두는 리소스. "냉장고에 붙여둔 레시피 카드".
지난 장 방식만으로는 부족한 3가지 상황
지난 장에서 배운 '오래 걸리는 작업(LRO)'은 비동기 작업을 추적하는 데는 훌륭합니다. 하지만 같은 일을 반복해야 할 땐 이것만으로는 부족합니다. 세 가지 장면을 봅시다.
상황 1 — 설정이 너무 많다
잘못된 예 (매번 다 넘기기)
BackupChatRoom({
id: "chatRooms/1",
destination: "s3://my-bucket/backup",
compressionFormat: "bz2",
encryptionKey: "ak-12345-secret-key"
})
이 호출을 매일 손으로 다시 친다고 생각해보세요. 저장 위치, 압축 방식, 암호화 키를 매번 정확히 넣어야 합니다. 누군가의 노트북에 적힌 이 값이 틀어지면 백업이 망가집니다. 그래서 이 값들을 어디에 안전하게 저장하느냐가 매우 중요해집니다.
올바른 방향: 이 설정을 서버에 잡(Job)으로 한 번만 저장해두는 것.
상황 2 — 설정하는 사람과 실행하는 사람을 나누고 싶다
회사를 떠올려봅시다.
개발팀 (설정 담당) 운영팀 (실행 담당)
───────────────── ─────────────────
잡 만들기/고치기 가능 잡 실행만 가능
암호화 키 설정 잡 설정은 못 바꿈
백업 위치 결정 암호화 키는 못 봄
직접 실행은 안 함 "백업 실행" 버튼만 누름
매번 모든 설정을 넘겨야 하는 방식이라면, 실행하는 사람도 암호화 키를 알아야 합니다. 그러면 권한을 나눌 수가 없습니다. 설정과 실행을 따로 떼어내야 "운영팀은 키를 몰라도 실행만 가능"하게 만들 수 있습니다.
상황 3 — 서버가 알아서 정해진 시각에 돌려주면 좋겠다
클라이언트가 스케줄 관리 서버가 스케줄 관리
───────────────────── ─────────────────────
내 컴퓨터가 항상 켜져 있어야 함 서버가 자동으로 실행
바깥 예약 프로그램에 의존 API가 스스로 일정 관리
설정값이 예약 스크립트에 흩어짐 설정값이 잡 하나에 모여 있음
내 노트북에 예약을 걸어두면, 노트북이 꺼지면 백업도 안 돕니다. 설정을 잡으로 모아두면, 서버가 "매일 새벽 3시에 이 잡 실행"을 알아서 해줄 수 있습니다.
(스케줄을 거는 구체적 방법까지는 이 장에서 다루지 않습니다. 여기선 "잡이 있으면 그런 게 가능해진다"까지만 알면 됩니다.)
11.2 큰 그림 — 작업을 두 단계로 쪼갠다
먼저 터지는 장면부터
설정과 실행을 한 덩어리로 묶으면, 위에서 본 세 가지 문제가 모두 한꺼번에 따라옵니다. 설정이 길고, 권한이 안 나뉘고, 스케줄도 못 겁니다.
그게 뭐냐면, 해법은 의외로 단순합니다. 한 덩어리를 두 단계로 쪼개는 겁니다.
핵심 — '설정과 실행의 분리'
[기존 방식 — 주문형 메서드]
사용자 ──(설정 + 실행 한 번에)──> API ──> 백엔드
[새 방식 — 잡 패턴]
1단계: 설정
사용자 ──(잡 만들기)──> API ──> 잡 리소스로 저장
2단계: 실행 (언제든, 몇 번이든)
사용자 ──(잡 실행)──> API ──> 진행 영수증 생성 ──> 백엔드 실행
용어 한 줄 정리 - 잡 리소스(Job Resource): 작업 설정을 담아 서버에 저장해둔 리소스. "서버에 보관된 레시피". - run 사용자 메서드: 저장된 잡을 실제로 돌리는 메서드. "레시피를 보고 실제로 요리 시작". - 설정과 실행의 분리: 만들기(설정)와 돌리기(실행)를 두 단계로 나누는 원칙. 이 장 전체의 뼈대입니다.
| 비유 | API/코드 | 위험·주의 |
|---|---|---|
| 레시피 카드 적어두기 | CreateJob: 설정을 잡으로 저장 | 이 단계에선 실제로 요리 안 함 |
| 그 레시피로 요리 시작 | RunJob: 잡을 실행 | 여러 번 눌러도 같은 레시피로 만들어짐 |
흐름을 이야기로 보면
- 사용자가 잡을 만든다. → 서버가 설정을 저장하고 "잡 만들었어요"라고 답한다.
- (시간이 흐른다. 그 사이 설정을 고칠 수도 있다.)
- 사용자가 "그 잡 실행해줘"라고 부른다. → 서버가 진행 영수증(오래 걸리는 작업)을 만들어 돌려주고, 백엔드가 실제 작업을 시작한다.
- 그 뒤로는 지난 장에서 배운 "영수증으로 진행 상황 물어보기"와 똑같이 흘러간다.
잡이 한 방에 푸는 세 문제 (요약표)
| 문제 | 잡이 푸는 방법 |
|---|---|
| 설정이 너무 많다 | 만들 때 한 번만 설정, 실행은 ID만 넘김 |
| 권한을 나누고 싶다 | 만들기(CRUD)와 실행(run)을 다른 메서드로 분리 |
| 스케줄이 필요하다 | 서버가 정해진 시각에 run을 대신 불러줌 |
(CRUD가 뭔지는 바로 다음 절에서 풀어 씁니다. 지금 몰라도 됩니다.)
11.3 어떻게 만드나 — 두 개의 부품
다시 돌려도 안전한 작업을 만들려면 부품이 딱 두 개 필요합니다.
- 잡 리소스 (설정을 담는 그릇) + 그걸 관리하는 표준 메서드들
- run 사용자 메서드 (실제로 돌리는 버튼)
하나씩 봅시다.
11.3.1 잡 리소스 — 설정을 담는 그릇
먼저 터지는 장면부터
앞에서 본 '매번 다 넘기는' 백업 호출을 다시 떠올려봅시다. 이걸 그릇 하나에 담아두면 어떻게 될까요?
기존 방식 (주문형 메서드)
// 부를 때마다 모든 값을 다시 넘긴다
abstract class ChatRoomApi {
@post("/{id=chatRooms/*}:backup")
BackupChatRoom(req: BackupChatRoomRequest):
Operation<BackupChatRoomResponse, BackupChatRoomMetadata>;
}
interface BackupChatRoomRequest {
id: string; // 백업할 채팅방
destination: string; // 저장 위치 (예: s3://bucket/path)
compressionFormat: string; // 압축 방식 (예: "bz2")
encryptionKey: string; // 암호화 키
}
여기서 Operation<응답, 메타데이터>는 지난 장의 "진행 영수증"입니다.
BackupChatRoomResponse는 끝났을 때 받는 결과(백업된 위치),
BackupChatRoomMetadata는 진행 중에 보는 중간 정보(처리한 메시지 수 등)예요.
잡으로 바꾸기
// 설정을 통째로 담는 그릇
interface BackupChatRoomJob {
id: string; // 고유 이름표 (서버가 자동으로 붙여줌)
chatRoom: string; // 백업할 채팅방
destination: string; // 저장 위치
compressionFormat: string; // 압축 방식
}
요청에 넣던 값들이 이제 잡 안으로 통째로 들어왔습니다. 한 번 만들어두면, 다음엔 이 잡의 이름표만 부르면 됩니다.
| 비유 | API/코드 | 위험·주의 |
|---|---|---|
| 매번 풀 주문서 쓰기 | BackupChatRoomRequest 매번 채우기 | 길수록 실수 |
| 주문서를 '일일백업'으로 저장 | BackupChatRoomJob 리소스로 보관 | 설정은 서버에 한 번만 |
| "'일일백업' 실행" | 이름표(id)만 넘겨 실행 | 추가 설정 안 넘김 |
잡을 관리하는 표준 메서드 (CRUD)
잡도 하나의 리소스이므로, 리소스를 다루는 기본 동작들이 따라옵니다. 이 다섯 가지를 묶어서 흔히 CRUD라고 부릅니다.
용어 한 줄 정리 - CRUD: 만들기(Create)·읽기(Read)·고치기(Update)·지우기(Delete)의 앞글자. "리소스를 다루는 기본 4종 세트"입니다. 목록 보기(List)도 흔히 함께 묶습니다.
| 표준 메서드 | 하는 일 |
|---|---|
| GetBackupChatRoomJob | 백업 잡의 세부 설정을 보여준다 |
| CreateBackupChatRoomJob | 새 백업 잡을 만든다 |
| UpdateBackupChatRoomJob | 기존 백업 잡의 설정을 고친다 |
| DeleteBackupChatRoomJob | 기존 백업 잡을 지운다 |
| ListBackupChatRoomJobs | 백업 잡들을 목록으로 본다 |
한 걸음 더 ▸ 잡을 못 고치게 막을 수도 있다 (불변성)
기본은 위처럼 고칠 수 있게(Update 허용) 두는 게 단순합니다. 그런데 한 가지 까다로운 경우가 있습니다.
용어 한 줄 정리 - 불변성(Immutability): 한 번 만든 것을 못 바꾸게 하는 성질. "잉크로 적어서 못 지우는 것".
잘못된 시나리오: 잡이 한창 돌고 있는데 그 잡의 설정을 누가 바꿔버리면? 지금 돌던 작업이 옛 설정인지 새 설정인지 헷갈리는 사고가 납니다. 이걸 "동시성 문제"라고 부릅니다. (두 일이 같은 순간에 부딪히는 것.)
선택지는 둘입니다.
고칠 수 있는 잡 (Update 허용)
좋음: 설정 변경이 유연하다
주의: 돌리는 중에 고치면 부딪힐 수 있다
못 고치는 잡 (Update 막음 = 불변)
좋음: 부딪힐 일이 없다
좋음: 설정이 안 변하니 결과가 예측 가능
불편: 바꾸려면 지우고 새로 만들어야 한다
돌리는 중 변경이 걱정되면, Update 메서드를 아예 빼버리는 것도 방법입니다.
11.3.2 run 사용자 메서드 — 잡을 실제로 돌리기
먼저 터지는 장면부터
잡을 만들어만 두면 아무 일도 안 일어납니다. 레시피만 적고 요리를 안 한 셈이죠. 그래서 "이 잡 실행해!"라고 부르는 버튼이 따로 필요합니다.
그게 뭐냐면 — run 메서드
run 메서드는 저장된 잡을 실제로 돌리는 버튼입니다. 세탁기에 비유하면 딱 맞습니다.
잡 만들기 = 세탁 코스 설정해서 '코스 1'로 저장
("울 코스, 30도, 1200rpm")
run 실행 = 시작 버튼 누르기
('코스 1' 실행!)
매번 설정 안 하고 버튼만 누름
다음 주에도, 다다음 주에도 같은 코스로 실행 가능
코드로 보면
abstract class ChatRoomApi {
@post("/{id=backupChatRoomJobs/*}:run")
RunBackupChatRoomJob(req: RunBackupChatRoomJobRequest):
Operation<RunBackupChatRoomJobResponse,
RunBackupChatRoomJobMetadata>;
}
interface RunBackupChatRoomJobRequest {
id: string; // ← 실행할 잡의 이름표만! 추가 설정 없음
}
가장 중요한 점: 입력이 잡의 id 하나뿐입니다. 모든 설정은 이미 잡 안에 들어 있으니까요.
| 비유 | API/코드 | 위험·주의 |
|---|---|---|
| 세탁기 시작 버튼 | RunBackupChatRoomJob | 입력은 잡 id 하나뿐 |
| 같은 코스 매주 돌리기 | 같은 잡을 여러 번 run | 그래서 "다시 돌려도 안전" |
run의 네 가지 규칙
1. 입력은 잡의 id 하나뿐이다
실행할 때 추가 설정을 주지 않는다
2. run도 진행 영수증(Operation)을 돌려준다
지난 장의 "물어보며 기다리기"를 그대로 쓴다
3. 응답·메타데이터 이름이 잡 맥락에 맞게 바뀐다
BackupChatRoomResponse → RunBackupChatRoomJobResponse
내용은 같고 이름만 바뀐다
4. 같은 잡을 몇 번이고 실행할 수 있다
그래서 이름이 "재수행 가능(Rerunnable)"이다
잘못된 예 vs 올바른 예
잘못된 예: run을 부르면서 destination, encryptionKey를 또 넘긴다
→ 잡을 만든 의미가 사라진다. 매번 다 넘기는 옛날 방식으로 되돌아감.
올바른 예: run에는 잡 id만 넘긴다
→ 설정은 잡에 있고, 실행은 버튼만 누른다.
11.3.3 익스큐션(Execution) — 실행 결과를 영구히 남기기
먼저 터지는 장면부터
이런 적 있죠? "지난달 분석 결과 좀 보여줘." 그런데 찾아보니 없습니다. 사라진 겁니다.
왜 사라졌을까요? 지난 장에서 배운 진행 영수증(LRO)은 영원하지 않기 때문입니다.
잡 실행 → 진행 영수증 생성 → 30일 지나면 자동 삭제!
"지난달 분석 결과 보여줘" → 없음(404)
영수증은 "지금 이 작업 어떻게 돼가나"를 잠깐 추적하는 용도라 오래 안 남습니다. 그런데 어떤 결과는 영원히 보관해야 합니다.
그게 뭐냐면 — 익스큐션 리소스
그래서 결과를 따로, 영구히 저장하는 리소스를 둡니다. 이게 익스큐션입니다.
용어 한 줄 정리 - 익스큐션(Execution): 잡을 한 번 돌릴 때마다 나오는 결과를 영구 저장하는 리소스. "시험을 칠 때마다 나오는 성적표".
잡 실행 → 진행 영수증 생성 (임시, 곧 사라짐)
→ 익스큐션 생성 (영구!)
"지난달 분석 결과 보여줘" → 익스큐션에서 조회 (남아 있음)
| 비유 | API/코드 | 위험·주의 |
|---|---|---|
| 시험 설정 (50문제, 90분) | 잡(Job) | 설정 자체 |
| 시험 성적표 (홍길동, 85점, 응시일) | 익스큐션(Execution) | 칠 때마다 한 장씩 쌓임 |
| 영수증 30일 뒤 폐기 | 진행 영수증(LRO) | 결과 영구 보관엔 부족 |
같은 시험(잡)을 여러 번 치면(run), 성적표(익스큐션)가 회차별로 쌓입니다.
analyzeChatRoomJobs/job-1/executions/exec-1 ← 1회차 결과
analyzeChatRoomJobs/job-1/executions/exec-2 ← 2회차 결과
analyzeChatRoomJobs/job-1/executions/exec-3 ← 3회차 결과
모든 잡에 익스큐션이 필요한 건 아니다
여기가 헷갈리기 쉬운 부분입니다. 결과가 이미 다른 곳에 잘 남는다면, 익스큐션은 필요 없습니다.
익스큐션이 필요 없는 경우
- 백업 잡 → 결과가 바깥 저장소(S3)에 이미 저장됨
- 리소스를 새로 만드는 잡 → 결과가 새 API 리소스로 이미 존재함
→ 진행 영수증의 결과 타입으로 충분
익스큐션이 필요한 경우
- 분석 잡 → 감정 점수·복잡도 같은 결과를 어딘가에 남겨야 하는데
새 리소스를 만들지도, 바깥에 저장하지도 않음
→ 익스큐션으로 결과를 보관
판단 기준은 단순합니다. "결과가 이미 어딘가에 남나?" → 그렇다면 익스큐션 불필요. "결과가 어디에도 안 남나?" → 그렇다면 익스큐션으로 남긴다.
익스큐션 리소스 예시
interface AnalyzeChatRoomJobExecution {
id: string; // 고유 이름표
job: AnalyzeChatRoomJob; // 실행에 쓴 잡의 스냅샷(그 순간 사진)
sentenceComplexity: number; // 문장 복잡도 결과
sentiment: number; // 감정 분석 결과
abuseScore: number; // 부적절 문구 점수
}
job 필드가 잡의 "스냅샷"을 담는 점에 주목하세요.
나중에 잡 설정이 바뀌어도, 이 성적표는 "그때 그 설정으로 쳤다"는 기록을 그대로 간직합니다.
실행하면 결과로 익스큐션을 돌려준다
abstract class ChatRoomApi {
@post("/analyzeChatRoomJobs")
CreateAnalyzeChatRoomJob(req: CreateAnalyzeChatRoomJobRequest):
AnalyzeChatRoomJob;
@post("/{id=analyzeChatRoomJobs/*}:run")
RunAnalyzeChatRoomJob(req: RunAnalyzeChatRoomJobRequest):
Operation<AnalyzeChatRoomJobExecution, // ← 결과가 익스큐션!
RunAnalyzeChatRoomJobMetadata>;
}
익스큐션과 진행 영수증의 관계
RunJob() 호출
│
▼
┌──────────────────┐
│ 진행 영수증 생성 │ 임시 (곧 사라짐)
│ done: false │
└────────┬─────────┘
│
작업 진행 중...
│
▼
┌──────────────────┐
│ 백엔드 작업 완료 │
└────────┬─────────┘
│
┌─────┴─────┐
▼ ▼
┌────────────┐ ┌──────────────────┐
│ 진행 영수증 │ │ 익스큐션 리소스 │ 영구 보관!
│ done: true │ │ 분석 결과 저장 │
│ result: │ │ id: "exec-1" │
│ 익스큐션 ──┼─│ sentiment: 0.82 │
│ 참조 │ │ abuseScore: 0.03 │
└────────────┘ └──────────────────┘
30일 뒤 삭제 계속 남아 있음
익스큐션의 표준 메서드 — 읽기 위주, 만들기·고치기는 없다
여기서 중요한 규칙이 나옵니다. 익스큐션은 사용자가 직접 만들거나 고칠 수 없습니다.
| 표준 메서드 | 되는가? |
|---|---|
| GetAnalyzeChatRoomJobExecution (하나 조회) | 된다 |
| ListAnalyzeChatRoomJobExecutions (목록 조회) | 된다 |
| DeleteAnalyzeChatRoomJobExecution (삭제) | 선택적으로 된다 |
| Create (직접 만들기) | 안 된다 |
| Update (고치기) | 안 된다 |
익스큐션은 오직 run 메서드가 내부에서만 만들어줍니다.
용어 한 줄 정리 - 읽기 전용 리소스: 조회만 되고 사용자가 직접 만들거나 못 고치는 리소스. "전광판처럼 보기만 되고 손은 못 대는 것".
익스큐션은 잡의 자식으로 둔다 (계층 구조)
/analyzeChatRoomJobs/job-1 ← 잡 리소스
/analyzeChatRoomJobs/job-1/executions/exec-1 ← 익스큐션 1
/analyzeChatRoomJobs/job-1/executions/exec-2 ← 익스큐션 2
왜 잡 밑에 둘까요? "이 잡의 실행 결과들 보여줘"라는 질문에 자연스럽게 답하기 위해서입니다. 성적표는 그 시험에 딸린 것이니, 시험 폴더 안에 두는 게 맞습니다.
진행 영수증이 맨 위 /operations 한 곳에 모이는 것과 달리,
익스큐션은 특정 잡 안에서만 살아갑니다.
11.3.4 다 합친 최종 API 정의
이제 잡·run·익스큐션을 한 API로 모아봅시다. 처음 보면 길지만, 방금 본 세 부품을 그냥 붙인 것뿐입니다.
abstract class ChatRoomApi {
// ── 잡 리소스 표준 메서드 ──
@post("/analyzeChatRoomJobs")
CreateAnalyzeChatRoomJob(req: CreateAnalyzeChatRoomJobRequest):
AnalyzeChatRoomJob;
@get("/analyzeChatRoomJobs")
ListAnalyzeChatRoomJobs(req: ListAnalyzeChatRoomJobsRequest):
ListAnalyzeChatRoomJobsResponse;
@get("/{id=analyzeChatRoomJobs/*}")
GetAnalyzeChatRoomJob(req: GetAnalyzeChatRoomJobRequest):
AnalyzeChatRoomJob;
@patch("/{resource.id=analyzeChatRoomJobs/*}")
UpdateAnalyzeChatRoomJob(req: UpdateAnalyzeChatRoomJobRequest):
AnalyzeChatRoomJob;
// ── run 사용자 메서드 (잡 실행) ──
@post("/{id=analyzeChatRoomJobs/*}:run")
RunAnalyzeChatRoomJob(req: RunAnalyzeChatRoomJobRequest):
Operation<AnalyzeChatRoomJobExecution,
RunAnalyzeChatRoomJobMetadata>;
// ── 익스큐션 표준 메서드 (읽기 전용) ──
@get("/{parent=analyzeChatRoomJobs/*}/executions")
ListAnalyzeChatRoomJobExecutions(
req: ListAnalyzeChatRoomJobExecutionsRequest):
ListAnalyzeChatRoomJobExecutionsResponse;
@get("/{id=analyzeChatRoomJobs/*/executions/*}")
GetAnalyzeChatRoomJobExecution(
req: GetAnalyzeChatRoomJobExecutionRequest):
AnalyzeChatRoomJobExecution;
}
// ── 잡 리소스 ──
interface AnalyzeChatRoomJob {
id: string;
chatRoom: string;
destination: string;
compressionFormat: string;
}
// ── 익스큐션 리소스 ──
interface AnalyzeChatRoomJobExecution {
id: string;
job: AnalyzeChatRoomJob; // 실행 순간의 잡 스냅샷
sentenceComplexity: number;
sentiment: number;
abuseScore: number;
}
// ── 요청/응답 ──
interface RunAnalyzeChatRoomJobRequest {
id: string; // 잡 id만 전달!
}
interface ListAnalyzeChatRoomJobExecutionsRequest {
parent: string; // 예: "analyzeChatRoomJobs/job-1"
filter: string;
}
읽는 법 - 위쪽 4개: 잡을 만들고/보고/고치고/목록 보는 표준 메서드. - 가운데 1개: 잡을 실제로 돌리는 run. - 아래쪽 2개: 익스큐션을 조회·목록 보는 읽기 전용 메서드. (만들기·고치기가 없다는 점을 다시 확인하세요.)
부분 완성 (빈칸 채우기) - run 메서드의 요청에는 _____ 하나만 들어간다. → 정답: 잡의 id - 익스큐션에 없어야 하는 두 메서드는? → 정답: Create, Update
11.4 좋은 점과 번거로운 점
좋은 점
- 설정과 실행을 나눠서 관리가 깔끔하다
- 권한을 세분화해 보안이 강해진다
- 서버가 알아서 정해진 시각에 돌릴 수 있다
- 같은 설정으로 몇 번이고 다시 돌릴 수 있다
한 걸음 더 ▸ 고려할 점 (트레이드오프)
용어 한 줄 정리 - 트레이드오프(Trade-off): 하나를 얻으면 다른 하나를 내주는 맞바꿈. "이불을 위로 당기면 발이 나오는 상황".
1. 잡 방식만이 정답은 아니다
간단한 일회성 작업은 그냥 주문형 메서드가 더 편하다.
2. 권한을 더 잘게 나눌 수도 있다
설정값 하나하나까지 제한하는 고급 권한도 가능하지만,
그만큼 구현이 복잡해진다.
3. 익스큐션을 쓰면 결과를 영구 보관할 수 있다
영수증 목록을 걸러내는 것보다 깔끔하다.
다만 잡과 연결된 결과만 따로 모아야 하는 수고가 든다.
4. 사용자 입장에선 번거로울 수 있다
한 번에 끝낼 일을 설정·실행 두 단계로 나누는 셈이니까.
| 비유 | API/코드 | 위험·주의 |
|---|---|---|
| 자주 시키는 단골 메뉴 등록 | 잡 패턴 채택 | 반복·복잡 설정에 유리 |
| 평생 한 번 시킬 메뉴 | 주문형 메서드 유지 | 단발성엔 잡이 과함 |
11.5 스스로 확인하기 (연습문제)
문제 1. 설정하는 권한과 실행하는 권한을 어떻게 구분하나요?
답 잡의 CRUD 메서드(Create·Update·Delete) 권한이 곧 '설정 권한'입니다. run 메서드 권한이 곧 '실행 권한'입니다. 두 메서드가 따로 있으니, 각각에 누가 접근할지 따로 정할 수 있습니다. 예: 개발팀은 잡 만들기/고치기만, 운영팀은 잡 실행만.
문제 2. 잡을 실행해서 새 리소스가 생기면, 결과는 익스큐션인가요 아니면 그 새 리소스인가요?
답 새로 생긴 리소스가 결과입니다. 예를 들어 채팅방을 가져오는 잡을 실행하면 새 채팅방 리소스가 생깁니다. 이때는 그 새 리소스 타입이 결과가 됩니다. 익스큐션은 새 리소스도 안 만들고 바깥에도 안 저장하는 경우, 즉 결과가 API 안 어디에도 안 남을 때만 씁니다.
문제 3. 익스큐션을 사용자가 직접 만들면 왜 안 되나요?
답 익스큐션은 run 메서드가 실제로 작업을 돌린 결과로만 생겨야 합니다. 사용자가 직접 만들 수 있으면, 작업을 안 돌리고도 가짜 성적표를 찍어낼 수 있습니다. 또 익스큐션은 한 번 생기면 안 바뀌어야(불변) 하므로 고치기(Update)도 두지 않습니다. 그래서 조회(Get·List)와 선택적 삭제(Delete)만 둡니다.
한눈 요약
- 다시 돌려도 안전한 작업은 '설정'과 '실행'을 두 단계로 나누는 방법이다.
- 잡 리소스를 먼저 만들어 설정을 담아두고, 나중에 run 메서드로 그 잡을 실행한다.
- 잡이 기존 리소스를 다루지도, 새 리소스를 만들지도 않는다면, 결과는 보통 익스큐션 리소스로 남는다.
- 익스큐션은 읽기 전용에 가깝다. 조회·목록은 되지만, 직접 만들거나 고칠 수는 없다.
부록 A: Flask/Python으로 만들어보는 재수행 가능 작업
아래는 채팅방 분석 잡을 설정하고 실행하는 작은 API 서버입니다. 앞에서 본 잡·run·익스큐션·진행 영수증이 실제 코드로 어떻게 생겼는지 보여줍니다. (전체를 외울 필요는 없습니다. "아, 이렇게 생겼구나" 정도면 충분합니다.)
"""
재수행 가능 작업(Rerunnable Job) 패턴을 Flask로 구현한 예제.
채팅방 메시지 분석 잡을 설정하고 실행하는 API 서버.
"""
from flask import Flask, jsonify, request
import uuid
import time
import threading
import random
from datetime import datetime, timedelta
app = Flask(__name__)
# 저장소 (실제로는 DB 사용)
jobs = {} # 잡 리소스
executions = {} # 익스큐션 리소스
operations = {} # 진행 영수증(LRO)
# ── 헬퍼 함수 ──
def create_operation(result_type_hint, metadata=None):
"""새 진행 영수증(Operation)을 생성한다."""
op_id = f"operations/{uuid.uuid4().hex[:8]}"
operations[op_id] = {
"id": op_id,
"done": False,
"expireTime": (datetime.now() + timedelta(days=30)).isoformat(),
"result": None,
"metadata": metadata or {}
}
return operations[op_id]
# ── 백그라운드 분석 작업 ──
def analyze_background(op_id, job_data):
"""
백그라운드에서 채팅방 분석을 수행한다.
완료 시 익스큐션 리소스를 생성한다.
"""
total = 200
for i in range(0, total + 1, 40):
time.sleep(1)
if op_id not in operations:
return
operations[op_id]["metadata"]["messagesProcessed"] = i
operations[op_id]["metadata"]["messagesCounted"] = total
# 익스큐션 리소스 생성 (영구 보관)
exec_id = f"{job_data['id']}/executions/{uuid.uuid4().hex[:6]}"
execution = {
"id": exec_id,
"job": job_data.copy(), # 잡 스냅샷 저장
"sentenceComplexity": round(random.uniform(0.3, 0.9), 2),
"sentiment": round(random.uniform(-1.0, 1.0), 2),
"abuseScore": round(random.uniform(0.0, 0.5), 2),
"createdAt": datetime.now().isoformat()
}
executions[exec_id] = execution
# 진행 영수증 완료 처리 (결과 = 익스큐션 참조)
operations[op_id]["done"] = True
operations[op_id]["result"] = execution
# ── 잡 표준 메서드 (CRUD) ──
@app.route("/analyzeChatRoomJobs", methods=["POST"])
def create_job():
"""새 분석 잡을 생성한다."""
data = request.json or {}
job_id = f"analyzeChatRoomJobs/{uuid.uuid4().hex[:8]}"
job = {
"id": job_id,
"chatRoom": data.get("chatRoom", "chatRooms/1"),
"destination": data.get("destination", ""),
"compressionFormat": data.get("compressionFormat", "none"),
}
jobs[job_id] = job
return jsonify(job), 201
@app.route("/analyzeChatRoomJobs", methods=["GET"])
def list_jobs():
"""모든 분석 잡을 목록으로 조회한다."""
return jsonify({"results": list(jobs.values())})
@app.route("/analyzeChatRoomJobs/<job_id>", methods=["GET"])
def get_job(job_id):
"""특정 분석 잡의 세부 설정을 조회한다."""
full_id = f"analyzeChatRoomJobs/{job_id}"
job = jobs.get(full_id)
if not job:
return jsonify({"code": "NOT_FOUND", "message": "잡을 찾을 수 없습니다"}), 404
return jsonify(job)
@app.route("/analyzeChatRoomJobs/<job_id>", methods=["PATCH"])
def update_job(job_id):
"""기존 분석 잡의 설정을 수정한다."""
full_id = f"analyzeChatRoomJobs/{job_id}"
job = jobs.get(full_id)
if not job:
return jsonify({"code": "NOT_FOUND", "message": "잡을 찾을 수 없습니다"}), 404
data = request.json or {}
for key in ["chatRoom", "destination", "compressionFormat"]:
if key in data:
job[key] = data[key]
return jsonify(job)
@app.route("/analyzeChatRoomJobs/<job_id>", methods=["DELETE"])
def delete_job(job_id):
"""기존 분석 잡을 삭제한다."""
full_id = f"analyzeChatRoomJobs/{job_id}"
if full_id not in jobs:
return jsonify({"code": "NOT_FOUND", "message": "잡을 찾을 수 없습니다"}), 404
del jobs[full_id]
return "", 204
# ── run 사용자 메서드 (잡 실행) ──
@app.route("/analyzeChatRoomJobs/<job_id>:run", methods=["POST"])
def run_job(job_id):
"""잡을 실행한다. 진행 영수증(Operation)을 반환한다."""
full_id = f"analyzeChatRoomJobs/{job_id}"
job = jobs.get(full_id)
if not job:
return jsonify({"code": "NOT_FOUND", "message": "잡을 찾을 수 없습니다"}), 404
# 진행 영수증 생성
op = create_operation(
result_type_hint="AnalyzeChatRoomJobExecution",
metadata={"messagesProcessed": 0, "messagesCounted": 0}
)
# 백그라운드에서 분석 실행
thread = threading.Thread(
target=analyze_background,
args=(op["id"], job)
)
thread.daemon = True
thread.start()
return jsonify(op), 202
# ── 익스큐션 표준 메서드 (읽기 전용) ──
@app.route("/analyzeChatRoomJobs/<job_id>/executions", methods=["GET"])
def list_executions(job_id):
"""특정 잡의 모든 실행 결과를 목록으로 조회한다."""
prefix = f"analyzeChatRoomJobs/{job_id}/executions/"
results = [e for eid, e in executions.items() if eid.startswith(prefix)]
return jsonify({"results": results})
@app.route(
"/analyzeChatRoomJobs/<job_id>/executions/<exec_id>",
methods=["GET"]
)
def get_execution(job_id, exec_id):
"""특정 실행 결과를 조회한다."""
full_id = f"analyzeChatRoomJobs/{job_id}/executions/{exec_id}"
execution = executions.get(full_id)
if not execution:
return jsonify({"code": "NOT_FOUND", "message": "실행 결과를 찾을 수 없습니다"}), 404
return jsonify(execution)
# ── 진행 영수증 조회 (지난 장에서 배운 것) ──
@app.route("/operations/<op_id>", methods=["GET"])
def get_operation(op_id):
"""진행 영수증 상태를 조회한다."""
full_id = f"operations/{op_id}"
op = operations.get(full_id)
if not op:
return jsonify({"code": "NOT_FOUND", "message": "작업을 찾을 수 없습니다"}), 404
return jsonify(op)
if __name__ == "__main__":
app.run(port=5001, debug=True)
부록 B: 클라이언트로 직접 써보기
서버를 켜두고, 이 클라이언트를 돌리면 흐름이 한눈에 보입니다. 1단계 잡 만들기 → 2단계 실행 → 3단계 같은 잡 또 실행 → 4단계 성적표(익스큐션) 목록 보기.
"""
재수행 가능 작업 API를 사용하는 클라이언트 예제.
1단계: 잡 생성 (설정)
2단계: 잡 실행 (여러 번 가능)
3단계: 익스큐션 결과 조회
"""
import requests
import time
BASE = "http://localhost:5001"
def main():
# ── 1단계: 분석 잡 생성 ──
print("=== 1단계: 잡 생성 ===")
resp = requests.post(f"{BASE}/analyzeChatRoomJobs", json={
"chatRoom": "chatRooms/1",
"destination": "s3://my-bucket/analysis",
"compressionFormat": "gzip"
})
job = resp.json()
job_id = job["id"].split("/")[1]
print(f" 잡 생성 완료: {job['id']}")
# ── 2단계: 잡 실행 (1회차) ──
print("\n=== 2단계: 잡 실행 (1회차) ===")
resp = requests.post(f"{BASE}/analyzeChatRoomJobs/{job_id}:run")
operation = resp.json()
op_id = operation["id"].split("/")[1]
print(f" 진행 영수증 생성: {operation['id']}")
# 물어보며 완료 대기
while not operation["done"]:
time.sleep(2)
resp = requests.get(f"{BASE}/operations/{op_id}")
operation = resp.json()
meta = operation.get("metadata", {})
print(f" 진행: {meta.get('messagesProcessed', 0)}"
f"/{meta.get('messagesCounted', 0)}")
print(f" 완료! 감정 점수: {operation['result']['sentiment']}")
# ── 3단계: 같은 잡을 다시 실행 (2회차) ──
print("\n=== 3단계: 잡 재실행 (2회차) ===")
resp = requests.post(f"{BASE}/analyzeChatRoomJobs/{job_id}:run")
operation2 = resp.json()
op_id2 = operation2["id"].split("/")[1]
while not operation2["done"]:
time.sleep(2)
resp = requests.get(f"{BASE}/operations/{op_id2}")
operation2 = resp.json()
print(f" 완료! 감정 점수: {operation2['result']['sentiment']}")
# ── 4단계: 익스큐션 목록 조회 ──
print("\n=== 4단계: 익스큐션 목록 조회 ===")
resp = requests.get(f"{BASE}/analyzeChatRoomJobs/{job_id}/executions")
execs = resp.json()["results"]
for i, ex in enumerate(execs, 1):
print(f" {i}회차: 감정={ex['sentiment']}, "
f"복잡도={ex['sentenceComplexity']}, "
f"부적절={ex['abuseScore']}")
if __name__ == "__main__":
main()
부록 C: 주문형 메서드 vs 잡 패턴 한눈 비교
| 구분 | 주문형 메서드 | 잡 패턴 (재수행 가능) |
|---|---|---|
| 호출 방식 | 매번 모든 설정 전달 | 설정(잡) 1회 + 실행 N회 |
| 설정 저장 | 클라이언트 책임 | 서버에 잡으로 저장 |
| 반복 실행 | 매번 동일 설정 전달 | run만 호출 (id만 전달) |
| 권한 분리 | 어렵다 | 만들기 ↔ 실행 분리 가능 |
| 스케줄링 | 클라이언트가 챙김 | 서버가 직접 챙김 |
| 결과 보관 | 진행 영수증 (만료됨) | 익스큐션 (영구) |
| 적합한 경우 | 일회성, 간단한 작업 | 반복적, 설정 복잡한 작업 |
| API 복잡도 | 낮음 | 높음 (잡 + run + 익스큐션) |
다음 장 예고: 다음 장에서는 또 다른 작업 패턴을 다룹니다. 지금 몰라도 됩니다. 이 장의 잡·실행·익스큐션만 챙기면 충분합니다.
클릭하거나 Space를 눌러 뒤집기